Skip to content

Run both self-hosted stacks on Modal, with a load-test harness - #11

Open
alexkroman wants to merge 3 commits into
mainfrom
modal-deployment
Open

Run both self-hosted stacks on Modal, with a load-test harness#11
alexkroman wants to merge 3 commits into
mainfrom
modal-deployment

Conversation

@alexkroman

Copy link
Copy Markdown

Adds Modal deployments for the sync and streaming stacks so they can run on
serverless GPUs instead of self-managed hardware, plus a harness that points at
either deployment with real audio and measures the concurrency it sustains.

Both were deployed and verified end to end on an L40S before this PR, then torn
down. Nothing here changes the existing compose stacks.

What's here

Path Purpose
sync/modal_app.py sync-api (L40S) + license-and-usage-proxy as two Modal functions
streaming/modal_app.py streaming-api + license proxy as functions, ASR as a GPU Sandbox
bench/ load-test harness for either stack, local or Modal
README sections deploy, verify, tear down, and the gotchas below

Verified

  • sync — 60 s WAV transcribed with 152 word-level timestamps; ~2.0 s
    server-side (~30x realtime). Throughput plateaus near 33x realtime at
    concurrency 8
    ; 401 on an empty Authorization header, as documented.
  • streaming — real turns over WebSocket, first turn ~7 s. 40 concurrent
    realtime sessions
    with zero failures; first failures at 64, which were
    connection-level rather than GPU saturation (throughput was still climbing at
    96).

Four things needed to lift these images onto Modal

  1. Clear the ENTRYPOINT. Modal prepends an image's ENTRYPOINT to its own
    runtime command, so the vendor binary otherwise swallows Modal's arguments,
    starts with default env, and the deployment code never runs. Every image uses
    .entrypoint([]).
  2. Interpreter handling differs per image. The Wolfi proxy exposes python3
    and must not get add_python; the sync and streaming ASR images keep
    theirs inside Bazel runfiles and need one injected.
  3. Install the Modal client into the image. Modal's runtime-mounted client
    dependencies do not resolve on these images' sys.path (symptom:
    ModuleNotFoundError: grpclib). The proxy bootstraps pip via ensurepip
    first, since Wolfi ships none.
  4. The license travels as a secret written to disk at startup — Modal has no
    bind mounts.

Streaming specifics

nginx is dropped: it only routes X-Model-Version across several ASR backends,
and Modal's autoscaler covers the load-balancing half.

The ASR runs in a Sandbox rather than a Function because a Modal tunnel's
lifetime is bound to the function call — a web_server body returns as soon
as it has started its server, Modal tears the tunnel down, and the port stops
answering while the container stays up.

Please read before using streaming for real traffic

The ASR's gRPC port is exposed with unencrypted_ports, i.e. a plaintext
public TCP socket carrying audio
, where compose keeps that hop on a private
bridge network. This is fine for testing and is called out in the README, but it
needs TLS or co-location first. Co-locating the API and ASR in one container is
currently blocked by colliding /opt/deps trees between the two Bazel images.

Also note the ASR Sandbox does not scale to zero — it holds an L40S until
stop_asr. The sync stack does scale to zero.

Checks

ruff check and ruff format --check pass on all five Python files, including
the two pre-existing examples. bench/README.md is markdownlint clean; the
pre-existing READMEs have 141 baseline violations (mostly MD013/MD060) that this
PR deliberately leaves alone.

🤖 Generated with Claude Code

alexkroman-assembly and others added 2 commits August 22, 2026 13:25
Run the sync and streaming stacks on Modal's serverless GPUs instead of
self-managed hardware, and add a harness that points at either deployment with
real audio to verify it and measure sustained concurrency.

Both stacks were deployed and verified end to end on an L40S:

- sync: 60s WAV transcribed in ~2.0s server-side (~30x realtime); throughput
  plateaus near 33x realtime at concurrency 8.
- streaming: WebSocket sessions return real turns; 40 concurrent realtime
  streams with no failures, first failures at 64.

Lifting these vendor images onto Modal needed four non-obvious adjustments,
documented inline and in each README:

- Modal prepends an image's ENTRYPOINT to its own runtime command, so every
  image clears it with .entrypoint([]) and each server is launched explicitly.
- Interpreter handling differs per image: the Wolfi proxy exposes python3 and
  must not get add_python, while the sync and streaming ASR images keep theirs
  inside Bazel runfiles and need one injected.
- Modal's runtime-mounted client dependencies do not resolve on these images'
  sys.path, so each installs the Modal client into the interpreter Modal
  actually launches (the proxy bootstraps pip via ensurepip first).
- The license travels as a secret written to disk at startup, since Modal has
  no bind mounts.

Streaming additionally drops nginx (it only routes X-Model-Version across
several backends) and runs the ASR in a Sandbox rather than a Function: a
tunnel's lifetime is bound to the function call, so a web_server body returning
tears the tunnel down while the container stays up.

The ASR's gRPC port is exposed as a plaintext public socket, where compose keeps
that hop on a private bridge network. This is testing-only and is called out in
the streaming README.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Bring the two Modal sections to parity and record what the deployments actually
did, so the numbers are not just in a chat log:

- sync: measured concurrency table (33x realtime plateau at concurrency 8), a
  teardown section, and a concrete harness invocation.
- streaming: teardown covering the Sandbox and both apps, since the Sandbox
  holds an L40S and does not scale to zero, plus a concrete harness invocation.
- bench: markdownlint clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment thread bench/harness.py Outdated
The harness printed a sample transcript on every run. Transcripts are derived
from whatever audio is submitted and can contain names, phone numbers, or other
personal data, which should not land in CI logs by default.

Correctness does not depend on printing them: --expect already fails the run
when the expected substring is missing, and the summary reports word and
character counts. So the sample is a human convenience and is now opt-in via
--show-transcript. When enabled, the text is truncated and its whitespace
collapsed, so untrusted output cannot forge additional log lines.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment thread bench/harness.py
@alexkroman
alexkroman requested a review from aleks-mitov August 24, 2026 20:31
@bgotthold-aai

Copy link
Copy Markdown
Contributor

perhaps we should not include streaming here if we can not support it in a secure way.

Comment thread sync/modal_app.py

import modal

REGISTRY = "344839248844.dkr.ecr.us-west-2.amazonaws.com"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think these already set in the .env

@aleks-mitov aleks-mitov left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Extensive review, as discussed in Slack. Verdict up front: this is genuinely useful work. The Modal-lift mechanics are real and correctly diagnosed, the code is clean, and the docs are unusually honest about their own gaps. I do not think it should merge as-is, for three reasons: the security posture is broader than the one issue Ben flagged, the headline streaming benchmark conclusion does not survive contact with the ASR's capacity cap, and two lifecycle cliffs (a 24-hour sandbox hard-stop, a 1-hour WebSocket ceiling) are undocumented. Everything is fixable, and most of it is small.

Method note: I verified the load-bearing claims against Modal's documentation and against the services' actual behavior, and re-ran the lint checks locally, so the inline comments state what the code does rather than what it looks like it does.

Suggested merge gate (everything else can be follow-up)

  1. Settle the streaming security question with data. One live test decides whether encrypted_ports + AAI_USE_SECURE_CHANNEL_TO_ASR_SERVICE=True closes the plaintext hop (the client side already supports it; details inline). If it passes, streaming can ship with TLS and an honest reachability caveat; if not, Ben's suggestion to hold streaming back is right.
  2. Close or clearly gate the public license proxy. It is publicly reachable and unauthenticated in BOTH stacks, and its usage-recording route is state-mutating, so dropping streaming alone would not close the most consequential exposure. Co-locating the proxy in the API container is the clean fix.
  3. Ship requires_proxy_auth=True on the API endpoints by default, with the README caveat as the opt-out rather than the control.
  4. Fix the env-override ordering so customer configuration wins over the hardcoded literals; today it silently regresses #10.
  5. Correct the benchmark conclusion (MAX_OPEN_STREAMS=32 is a hard cap masked by connect retries) and the autoscaler claim in the streaming docstring.
  6. Document the two lifecycle cliffs: the sandbox's 24h stop and the 1h WebSocket session ceiling; raise the latter's timeout.
  7. Fate-share the vendor processes with their containers so a post-startup crash does not black-hole traffic.
  8. Harden the harness's three failure modes (stereo input, missing session deadline, writer-thread stall) before it becomes the tool customers size deployments with.

What checked out

Worth recording so review energy goes where it matters: ruff check and ruff format --check pass as claimed (re-ran locally); dropping nginx is routing-safe with a single backend; the Sandbox-not-Function tunnel rationale matches Modal's docs; .entrypoint([]) is genuinely required; every env var name used in both Modal apps is a real configuration field; the "restarting the ASR invalidates the API" gotcha is real and the documented remedy is correct; the bench README's options table matches the argparse definitions exactly; and .gitignore covers .env, license.jwt, and *.jwt.

29 inline comments carry the details, each tagged [major]/[minor]/[nit] with a concrete fix.

Comment thread streaming/modal_app.py
app=sandbox_app,
image=asr_image,
gpu="L40S",
timeout=24 * 60 * 60,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[major] The Sandbox hard-stops after 24 hours and nothing detects it.

timeout=24 * 60 * 60 is the sandbox's maximum lifetime (and Modal's platform max), after which Modal terminates it, tunnel included. Nothing cleans the modal.Dict when that happens: address and sandbox_id stay populated, so warm streaming_api containers keep dialing the dead tunnel, and cold-started ones pass the if not address guard and boot "successfully" against a corpse. A customer returning after a day sees a green deployment that fails every session (close code 3005 after the API exhausts its connect retries), with no documented cause. Note this is a different failure mode from the Missing expected server metadata keys log the README's Restarting section quotes (that one fires when the endpoint answers but is not the ASR).

Suggested fix: (1) document the 24h lifetime in Deploy/Tear down and cross-link it from the Restarting section as the most likely trigger; (2) in streaming_api, also read sandbox_id and fail fast at startup when modal.Sandbox.from_id(sandbox_id).poll() is not None, raising the same "run start_asr" error; (3) optionally, a background thread in streaming_api that exits the container on sandbox death or address change, which would also remove the manual stop-and-redeploy step the README requires after every start_asr.

Comment thread streaming/modal_app.py
# SECURITY: this port is on the public internet and carries audio in the
# clear, where compose keeps the hop on a private bridge network. See
# "Security" in the README before running real traffic.
unencrypted_ports=[ASR_GRPC_PORT],

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[major] Concrete path to resolving Ben's plaintext concern, and the half TLS does not fix.

Confidentiality half: swap unencrypted_ports for encrypted_ports and set AAI_USE_SECURE_CHANNEL_TO_ASR_SERVICE=True. I checked the client side: when that flag is true the streaming API dials the ASR with standard TLS credentials using the default public trust roots, so no vendor change is needed if Modal's TLS tunnel presents a publicly trusted cert on the tunnel hostname. Modal documents that tunnels "terminate TLS automatically" but does not document the CA or whether the socket negotiates ALPN h2 (gRPC requires it), and the comment here says encrypted was never re-tested. One live test settles it: openssl s_client -alpn h2 against the tunnel, then a health check over a secure channel. Worth running before deciding whether streaming ships.

Reachability half: TLS does not authenticate clients. The tunnel address is on the public internet, so anyone who finds it gets free GPU inference on the customer's bill, and since MAX_OPEN_STREAMS=32 is a hard cap, 32 attacker-held streams starve every legitimate session. Complete fix is co-locating the API and ASR in one container so the hop stays on localhost (the Security section already names this; I would promote it to the recommendation), or holding the streaming recipe back until that works.

Comment thread streaming/modal_app.py
print(f"terminated {sandbox_id}")


@app.function(image=api_image, secrets=[license_secret], timeout=3600)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[major] timeout=3600 caps every WebSocket session at one hour.

Modal treats each WebSocket connection as a single function call ("WebSockets on Modal maintain a single function call per connection"), and the function-level timeout bounds each call. So a realtime session open past 1h is terminated mid-stream. The compose stack never severs sessions (nginx is configured with 10h timeouts). The load tests used 15-20s sessions, so this never surfaced.

Suggested fix: raise timeout here toward Modal's 24h max and document the resulting hard per-session ceiling in the README next to the security caveats (clients must reconnect). Also worth one line on why sync_api keeps 3600 (its requests are bounded by INFERENCE_TIMEOUT_SECONDS=30 anyway).

Comment thread streaming/modal_app.py
@modal.web_server(8080, startup_timeout=180)
def license_proxy():
_write_license()
subprocess.Popen(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[major] All four vendor binaries run unsupervised; a post-startup crash black-holes traffic.

Every @modal.web_server body here (both license_proxy functions, streaming_api, sync_api) does a bare subprocess.Popen and returns. Modal only verifies the port opens once at container start; there is no ongoing health check. If the binary exits later (the license proxy is the sharpest case, since the APIs shut themselves down on license failure, and the GPU process can OOM), the container stays alive, keeps counting toward autoscaling capacity, and requests routed to it fail until it happens to scale down. The compose stacks have healthchecks on all four services that catch exactly this.

Suggested fix at all four launch sites: keep the handle and fate-share, e.g.

proc = subprocess.Popen(...)
threading.Thread(target=lambda: (proc.wait(), os._exit(proc.returncode or 1)), daemon=True).start()

so a dead server kills the container and Modal replaces it. This also turns crash-after-bind into a visible restart loop instead of a silent black hole.

Comment thread streaming/modal_app.py

nginx (streaming-asr-lb) is dropped: it exists only to route X-Model-Version
across several ASR backends and to load-balance replicas. With one model,
Modal's autoscaler covers the second job and the first is unnecessary.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[major] "Modal's autoscaler covers the second job" is not true for the component nginx actually balanced.

The ASR is a single Sandbox pinned to one L40S, and Sandboxes do not autoscale; only the CPU streaming_api/license_proxy functions scale. The architecture also cannot grow ASR replicas without reintroducing routing (the modal.Dict holds exactly one address). The X-Model-Version half checks out (the header is routing metadata a single backend safely ignores), but a reader sizing for more concurrent streams and trusting this sentence is misled.

Suggested fix: correct the docstring (autoscaling applies only to the CPU functions) and add one README sentence: this deployment is capped at a single GPU backend; for more capacity run multiple deployments or use compose with nginx.

Comment thread bench/harness.py

with ThreadPoolExecutor(max_workers=1) as pool:
write_future = pool.submit(writer)
for message in ws:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[major] No read deadline: a live-but-silent server hangs the whole sweep.

for message in ws blocks without a timeout and nothing bounds a session's total duration. The websockets keepalive only unblocks a dead TCP connection; a server that accepts the upgrade and audio but never sends Termination (exactly the overload regime this harness exists to probe, and the PR itself reports connection-level failures at 64) leaves the thread waiting forever. run_level's f.result() has no timeout either, so one stuck session hangs the entire ramp: no table, no exit, at precisely the saturation point the tool is meant to find.

Suggested fix: compute a per-session deadline (e.g. audio_seconds / speed + open_timeout + margin), replace the iterator with ws.recv(timeout=remaining) returning Result(False, ..., detail="session deadline exceeded") on TimeoutError, and optionally pass a timeout to f.result() as a backstop.

Comment thread bench/harness.py
def quantile(p: float) -> float:
if not lat:
return float("nan")
return lat[min(int(len(lat) * p), len(lat) - 1)]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[minor] Off-by-one rank: p95 equals max for every level with n <= 20.

Nearest-rank quantile index is ceil(n * p) - 1; int(n * p) is one rank high, so with n <= 20 the printed p95 is always exactly the max (which covers most levels in the documented ramps, and every ramp table in this PR shows it), and with n = 2 the p50 reports the slower of the two as the median. idx = max(0, math.ceil(len(lat) * p) - 1) fixes it.

Comment thread bench/harness.py
"max": lat[-1] if lat else float("nan"),
"wall": wall,
"rps": len(ok) / wall if wall else 0.0,
"audio_x_realtime": (len(ok) * audio_seconds / wall) if wall else 0.0,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[minor] For streaming, this metric is tautological: it measures the client's own pacing.

Streaming sessions are realtime-paced by the writer, so wall ~= audio_seconds / speed and audio_x_realtime ~= ok * speed regardless of server capacity. Meanwhile the number that does reflect server health under load, per-session first_turn_s (already collected in Result.extra), is never aggregated; only the first ok result's extra is printed once. Suggest omitting xRT in streaming mode (print "-") or replacing it with first_turn_s percentiles, and scoping the README's saturation guidance ("throughput plateauing while latency climbs") to sync.

Comment thread bench/harness.py
ap.add_argument("--audio", required=True, help="16-bit PCM WAV")
ap.add_argument("--concurrency", type=int, default=1)
ap.add_argument("--ramp", help="comma-separated concurrency levels, e.g. 1,4,8,16")
ap.add_argument("--max-seconds", type=float, help="truncate audio to N seconds")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] --max-seconds can truncate the audio before the --expect word occurs, turning every request into a false failure that reads like a server problem (the README recommends --max-seconds 20 while --expect defaults to "assemblyai"). A note in the bench README ("pick --expect from the first N seconds when truncating"), or a startup warning when both flags are set with the default expect, avoids the trap.

Comment thread bench/README.md
Transcripts are not printed by default. They are produced from whatever audio
you submit and can contain personal data, which you generally do not want in CI
logs. Correctness is still enforced without them: `--expect` fails the run if
the expected substring is missing, and the summary reports word and character

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] Only true for sync. In streaming mode the summary carries first_turn_s and turn count, not words; the character count is what both modes print. (The rest of this README verified clean against the script: the options table matches the argparse definitions exactly, including defaults, and the exit-status claim matches main().)

aleks-mitov added a commit that referenced this pull request Aug 26, 2026
…only for verification)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LL3rQMpGXvmMHRJiffWMF1
aleks-mitov added a commit that referenced this pull request Aug 28, 2026
* feat(modal): standalone single-deploy packages for each stack

Repackage the Modal deployment so each self-hosted stack is a self-contained
Modal App deployed with one `modal deploy` — no start_asr step, no modal.Dict
address handshake, no cross-package dependency. Uses Modal Servers (@app.server)
with sibling URLs resolved via Server.from_name at startup.

- sync/modal_app.py                          -> aai-sync-u3pro
- streaming/modal_app_universal_3_5_pro.py   -> aai-streaming-u3pro
- streaming/modal_app_english_multilang.py   -> aai-streaming-english-multilang

Fixes carried over from the #11 review: autoscaling ASR Server instead of a
24h-capped Sandbox; no 1h WebSocket session ceiling; encrypted ASR hop via
h2_enabled + AAI_USE_SECURE_CHANNEL_TO_ASR_SERVICE (encrypted_ports alone
negotiates no ALPN); unauthenticated=False by default (Modal proxy auth);
fate-shared vendor processes; env-overridable sync audio limits.

Verified end to end on L40S (single deploy each): sync 152-word transcript,
autoscaled to 181x realtime at concurrency 8; streaming u3pro real turns over
the TLS h2 ASR hop; english+multilang routing both models through the nginx Lb.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LL3rQMpGXvmMHRJiffWMF1

* docs(samples): sample request scripts for each Modal stack

sample_sync.py (POST /transcribe), sample_streaming.py (live realtime turns,
model selection for the u3pro and english/multilang stacks), and a README.
Both support --concurrency / --load for a quick input-load sweep and Modal
proxy-auth headers. Verified live against all three deployed stacks.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LL3rQMpGXvmMHRJiffWMF1

* fix(samples): count final turns in streaming --load mode

The turn counter was appended only inside the live-printing branch, so
--load sessions always reported 0 turns even though turns arrived (first-turn
latency was correct). Track end_of_turn regardless of live; gate only printing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LL3rQMpGXvmMHRJiffWMF1

* refactor(modal): move stacks into dedicated sync_modal_stack/ and streaming_modal_stack/

Consolidate each stack's Modal artifacts into a self-contained top-level dir:

  sync_modal_stack/       modal_app.py, sample_sync.py, README.md
  streaming_modal_stack/  modal_app_universal_3_5_pro.py,
                          modal_app_english_multilang.py, sample_streaming.py, README.md

Each new dir carries its own deploy/verify/auth/teardown README. The compose
READMEs (sync/, streaming/) keep a one-line pointer instead of the full Modal
section, and the root README points at the two new dirs. No code behavior
changes; comment cross-references updated to the new paths.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LL3rQMpGXvmMHRJiffWMF1

* feat(modal): pin u3pro streaming-api and ASR to release-v1.0.1

The self-hosted-streaming-api v1.0.1 image carries the handshake-logging fix
(DeepLearning #19523: peer-aborted WebSocket handshakes log at WARNING, not
ERROR). Bump streaming-api and self-hosted-streaming-asr-universal-3-5-pro to
release-v1.0.1 in the u3pro stack via per-image tags; the license-and-usage-proxy
has no v1.0.1 and stays on v1.0.0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LL3rQMpGXvmMHRJiffWMF1

* fix(modal): address review findings + streaming-api v1.0.1 everywhere

Adversarial review of PR #12 (8 findings). Fixes:
- [major] english/multilang ASR MAX_OPEN_STREAMS + target_concurrency 32 -> 48
  (compose parity; avoids a ~50% GPU over-provision at scale).
- [major] streaming README Verify pointed at the bundled example client, which
  cannot send Modal proxy-auth headers and 401s on the default deploy; point it
  at sample_streaming.py with --modal-key/--modal-secret and note the caveat.
- [minor] sync/streaming README Verify curls now send Modal-Key/Modal-Secret
  (they 401'd on the default proxy-auth deploy).
- [minor] nginx Lb -> ASR hop now verifies the backend cert (grpc_ssl_verify on
  + ca-certificates trusted store), not just encrypts.
- [minor] fate-share reaper no longer reports a clean shutdown as a crash: a
  module-level _stopping event, set by @modal.exit stop(), distinguishes an
  intentional teardown from an unexpected vendor exit (all three apps).
- [minor] sample_streaming.py no longer crashes formatting first_turn_s when a
  session yields no word-bearing turns (prints n/a).
- [minor/question] english/multilang now pins streaming-api via its own API_TAG.

streaming-api bumped to release-v1.0.1 everywhere it is referenced (u3pro +
english/multilang Modal stacks, streaming/.env.example, root README) so both
streaming stacks carry the handshake-logging fix.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LL3rQMpGXvmMHRJiffWMF1

* chore: drop bench/harness.py from this branch (belongs to #11; local-only for verification)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LL3rQMpGXvmMHRJiffWMF1

* fix(modal): drop nginx grpc_ssl_verify on the LB->ASR hop (broke connectivity)

Verifying the backend cert (finding 4) fails against Modal's edge: nginx cannot
build the chain and errors "unable to get local issuer certificate", marking the
ASR backend down so every session 3005s (confirmed live). Revert to encrypt-only
(grpc_ssl_server_name on) and document the residual limitation in-place, the
finding's sanctioned alternative. The backend is unauthenticated by design;
co-location or i6pn is the real fix, per the README.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LL3rQMpGXvmMHRJiffWMF1

* refactor: group each service by deployment target (docker/ + modal/)

Per review feedback, nest deployment variants under each service dir instead of
top-level *_modal_stack dirs, so more targets (e.g. sagemaker) slot in cleanly:

  sync/docker/       (compose stack, moved from sync/)
  sync/modal/        (moved from sync_modal_stack/)
  streaming/docker/  (compose stack + nginx, moved from streaming/)
  streaming/modal/   (moved from streaming_modal_stack/)

Relative links and paths updated throughout (root README layout + pointers,
docker READMEs' ../README.md -> ../../README.md and modal pointers, modal
READMEs' example paths -> ../docker/example, sample-script docstrings, and the
cross-file comment references). No code behavior change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LL3rQMpGXvmMHRJiffWMF1

* docs(modal): align streaming security note with the i6pn findings

The internal-hop section presented co-location and i6pn as available fixes and
called the topology evaluation-suitable without qualification. Reframe to match
what the i6pn investigation found: the authenticated front door is the real
gate, the residual backend exposure is bounded, and taking the backends fully
private is a Modal placement limitation for this shape (GPU and CPU front-ends
land in different datacenters; i6pn does not bridge them; same-datacenter
placement is gated behind Modal sales).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LL3rQMpGXvmMHRJiffWMF1

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants